feat(ramp): wire onClose for user dismissal (Phase 8)#29919
Conversation
Closes Phase 8 of the Headless Buy plan. When the user backs out of the
headless flow without producing an order, the consumer's onClose
callback now fires with { reason: 'user_dismissed' } so external
consumers (notably MetaMask Pay's TransactionPayController) can detect
dismissal and clean up.
- Add useHeadlessSessionDismissal hook that fires the dismissal close on
unmount when the session is still in the registry
- Call the hook from HeadlessHost (the only headless entry under
quote-first) and fire closeSession synchronously from handleBack so
the close happens at the moment of intent, with the unmount cleanup
as a defense-in-depth fallback for back-gesture / programmatic nav
- Idempotent: closeSession no-ops on terminal sessions, so Phase 6
(completed), Phase 7 (failSession), Phase 5 restart and consumer
cancel paths each fire onClose exactly once before unmount and the
dismissal cleanup that follows is a no-op
- PLAN.md: check off Phase 8, relocate BuildQuote-dismissal bullet
(deferred to Phase 10), add Phase 9 Update reflecting MetaMask Pay's
awaitOrderTerminalState requirement, restructure Phase 10 to absorb
deferred Phase 5b
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
… open question Captures decisions and open questions from the May 6 2026 design thread (https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499) on how MetaMask Pay consumes the headless flow. - New Phase 9.5: HeadlessHost visual treatment. Pedro confirmed the Host must stay mounted (routing landing pad + nativeFlowError surface) but doesn't have to be visible. Two shapes evaluated: transparent overlay with consumer-rendered spinner (Pedro's pick) or bottom-sheet with the Host's own spinner. Final shape pending Lucas's design rec (May 13). Phase 8's dismissal contract is unaffected — back-press still fires onClose(user_dismissed). - Phase 9 Update: append the timeout open question Barbara raised (reply 36). Two API shapes worth considering during Phase 9 implementation: timeoutMs on awaitOrderTerminalState, or a registry-side per-session timeout that fires onError(TIMED_OUT) + onClose. Not blocking for v1. - Resolve a naming conflict: the auto-select-best-provider utility was tentatively listed as "Phase 9.5"; renamed to "follow-up phase" so the new visual-treatment phase can own the 9.5 number.
Captures two cross-cutting API rules so future contributors know the constraints before extending `useHeadlessBuy`: 1. Callbacks-only, three terminal events. No intermediate progress callbacks (onAuthStarted / onKycRequired / etc.) — they would couple consumers to ramp internals and force them to update on every flow change. 2. The consumer renders all visible UI. No render-shape props (loadingText / spinnerComponent / etc.). Headless Ramps is a behavior provider, not a UI provider — Phase 9.5 implements this on the Host side; the API side must stay this shape. Both principles were implicit in the API as designed but undocumented; making them explicit makes them defensible in PR review and harder to erode by accretion. Section sits between "Architecture at a glance" and Phase 1 so it is visible to anyone reading PLAN.md top-down.
…uild
Real-device verification of Phase 8 revealed that `useTransakRouting`
opens Checkout via `navigation.reset({ routes: [HEADLESS_HOST, CHECKOUT] })`,
which rebuilds the navigator with fresh route keys and unmounts the
original HeadlessHost instance — even though logically the user is
still in the headless flow. The first `useHeadlessSessionDismissal`
treated that unmount as a dismissal and fired
`closeSession({ reason: 'user_dismissed' })`. By the time the Transak
widget redirected back ~50s later, `getSession(id)` returned undefined
and the Phase 6 bypass fell through to `RAMPS_ORDER_DETAILS`, breaking
the headless contract on every successful buy.
Fix: the unmount cleanup now reads `navigation.getState()` and walks
its routes (recursively, for nested navigators). If HEADLESS_HOST is
still present, the unmount is a stack rebuild and the close is skipped.
If `getState()` throws (navigator torn down), the cleanup falls through
to close — treating a missing navigator as "user left" is the safe
default. The original Phase 8 termination paths (Phase 6 completed,
Phase 7 unknown, consumer cancellation, handleBack) keep working
unchanged because `getSession(id)` already returns undefined by the
time the cleanup runs.
Tests:
- 4 new tests under `stack-rebuild guard` in
useHeadlessSessionDismissal.test.ts covering: HEADLESS_HOST present
as direct route, HEADLESS_HOST in nested navigator state, absent
(true dismissal), and getState throwing. Suite is now 12 tests at
100% coverage.
- Existing HeadlessHost.test.tsx Dismissal block (23 tests) keeps
passing because the test mock's missing getState triggers the
defensive throw branch, which matches the existing "close on
unmount" assertions.
PLAN.md (Phase 10 polish surfaced during verification): added two
secondary goals so the next reviewer can sequence them.
- Goal 3 — Navigation/state cleanups: flatten the nested
RampTokenSelection descriptor in `startHeadlessBuy` (3-level
warning); move Checkout's `onNavigationStateChange` function out
of route params and into the session registry (state-restore
failure mode if the app is killed mid-Checkout).
- Goal 4 — Suppress the global order toast for headless orders
(Phase 7 follow-up): Phase 7 audited the in-flow toast call sites
but missed `processFiatOrder` in index.tsx, which fires
`showV2OrderToast` whenever a polled order's state transitions to
Completed. Fix shape: stamp `headless: true` on the order in the
three bypass paths; short-circuit the toast in processFiatOrder
while preserving Redux state + analytics parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
All changes are scoped to the Ramp/fiat on-ramp headless flow. The No other tags are needed: this is not a confirmation flow change (no SmokeConfirmations needed), not a swap change (no SmokeSwap), and doesn't touch navigation infrastructure broadly enough to warrant wider coverage. The changes are well-covered by unit tests and the risk is medium since it's a behavioral change to session lifecycle callbacks in a specific flow. Performance Test Selection: |
|



Description
This PR closes Phase 8 of the incremental Unified Buy (v2) headless buy plan (
app/components/UI/Ramp/headless/PLAN.md): when the user backs out of the headless flow without producing an order, the consumer'sonClosecallback now fires with{ reason: 'user_dismissed' }exactly once per session.Reason
onClose({ reason: 'completed' })afteronOrderCreated. Phase 7 (#29612) wiredfailSession→onClose({ reason: 'unknown' })afteronError. The single remaining termination path — user dismissal — left the session alive in the registry and the consumer never received a terminalonClose.TransactionPayController(MetaMask/core#8628). TPC's two-step flow (Fiat purchase → Intent transaction) can't sequence step II without a reliable terminalonClose. Production money account ships end of May.What changed
useHeadlessSessionDismissal— new hook inapp/components/UI/Ramp/headless/. On unmount (orheadlessSessionIdchange) the cleanup callscloseSession(id, { reason: 'user_dismissed' })— but only if the session is still in the registry and the navigator's current state no longer containsHEADLESS_HOST. The state inspection is the stack-rebuild guard described below; without it,useTransakRouting'snavigation.resetcalls would unmount the Host mid-flow and close the session prematurely.closeSessionis idempotent on terminal sessions, so Phase 6 / Phase 7 / consumer-cancel paths (which all delete the session before unmount) make the cleanup a no-op.useTransakRouting.navigateToWebviewModalCallbackopens Checkout vianavigation.reset({ routes: [HEADLESS_HOST, CHECKOUT] }). React Navigation rebuilds the stack with fresh route keys, which unmounts the originalHeadlessHostinstance even though logically the user is still inside the headless flow. The first version ofuseHeadlessSessionDismissaltreated that unmount as a dismissal and closed the session — by the time the Transak WebView redirected back ~50s later,getSession(id)returnedundefinedand the Phase 6 bypass fell through tonavigation.reset(...RAMPS_ORDER_DETAILS), breaking the headless contract on every successful buy. The hook now readsnavigation.getState()in the unmount cleanup and walks the routes (recursively, for nested navigators); ifHEADLESS_HOSTis still present, the unmount is a stack rebuild and the close is skipped. IfgetState()throws (navigator torn down), the cleanup falls through to close — true dismissal is the safe default. The[bypass]+[dismissal-cleanup]diagnostic logs that proved the bug have been removed from the final patch.HeadlessHost— calls the hook. Also firescloseSession({ reason: 'user_dismissed' })synchronously fromhandleBackso the close happens at the moment of intent rather than during unmount; the unmount cleanup remains a defense-in-depth fallback for back-gesture / programmatic nav.HeadlessHost.test.tsx—Dismissal (Phase 8)describe block (back/cancel buttons, mid-flow unmount, Phase-6-completed-before-unmount regression, Phase-7-error-before-unmount regression, mounted-on-already-cancelled). The existing "rejection-after-unmount" test was updated: post-Phase 8, unmount now fires the dismissal close, and the latecontinueWithQuoterejection still does not produce a secondonClose(the.catchre-reads the registry). Suite size: 23 tests.useHeadlessSessionDismissal.test.ts— 12 hook unit tests at 100% coverage. Eight original tests cover terminal-state idempotency, id changes, undefined/unknown ids. Four new tests under thestack-rebuild guarddescribe block cover the navigator-state check: HEADLESS_HOST present as a direct route, HEADLESS_HOST present in a nested navigator state, HEADLESS_HOST absent (true dismissal), andgetState()throwing (defensive fallback to close).index.ts— barrel-exports the hook so future Phase 5b can reuse it.PLAN.md(Phase 8 edits) — checks off Phase 8; relocates the BuildQuote-dismissal bullet to Phase 10 (since BuildQuote is no longer a headless entry under quote-first); adds a Phase 9 Update (May 2026) section reflecting that MetaMask Pay's TPC needs an imperativeawaitOrderTerminalState(orderId)Promise (not just the playground "Refresh" button) and the auto-select-best-provider utility called out in the same Apr 28 sync; restructures Phase 10 to absorb the deferred Phase 5b raw-params start mode and the BuildQuote dismissal that ships with it.PLAN.md(May 6 design-thread follow-ups) — inserts a new Phase 9.5 — HeadlessHost visual treatment sourced from the May 6 2026 design thread. Pedro confirmed the Host must stay mounted (routing landing pad + Phase 7 error surface) but doesn't have to be visible; the consumer (TPC / MMPay) renders the loading UI; back-arrow stays available so Phase 8's dismissal contract continues to apply. Final shape (transparent overlay vs bottom-sheet) pends Lucas's design rec (May 13). Also appends an open question under the Phase 9 Update about whether ramps needs an internal timeout (Barbara, same thread, reply 36) — not blocking for v1, two API shapes worth weighing during Phase 9 implementation.PLAN.md(Design principles section) — new top-level section between "Architecture at a glance" and Phase 1 capturing two cross-cutting API rules: (1) callbacks-only, three terminal events — no intermediate progress callbacks (onAuthStarted,onKycRequired, etc.) so consumers don't couple to ramp flow internals; (2) consumer renders all visible UI — no render-shape props (loadingText,spinnerComponent, etc.). Both rules were implicit in the API as designed but undocumented; making them explicit makes them defensible in PR review and harder to erode by accretion. Phase 9.5's Host strip-down is the implementation side of rule 2.PLAN.md(Phase 10 polish surfaced during verification) — Phase 8's on-device verification turned up two React Navigation warnings and one Phase-7-leak that didn't block this PR's fix but belong on the Phase 10 cleanup list. Added as Goal 3 — Navigation/state cleanups (flatten thestartHeadlessBuynestedRampTokenSelectiondescriptor; moveCheckout'sonNavigationStateChangefunction out of route params and into the session registry, since stashing functions in nav params breaks state restore) and Goal 4 — Suppress the global order toast for headless orders (Phase 7 follow-up) (Phase 7's audit caught the in-flowshowV2OrderToastcall sites but missed the FiatOrders background processor inindex.tsx; fix is to stamp orders with aheadless: trueflag in the bypass paths and short-circuit the toast inprocessFiatOrderwhile preserving Redux state + analytics). Each entry has aRisk if leftline so the next reviewer can decide priority.References
main(Phase 7 has merged).Tests
yarn jest --watchman=false app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/hooks/useTransakRouting.test.ts— 76 pass / 0 fail (12 dismissal hook + 23 HeadlessHost + 41 useTransakRouting).useHeadlessSessionDismissal.ts): 100% statements / 100% branches / 100% functions / 100% lines.yarn eslint app/components/UI/Ramp/headless/useHeadlessSessionDismissal.ts app/components/UI/Ramp/headless/useHeadlessSessionDismissal.test.ts app/components/UI/Ramp/hooks/useTransakRouting.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/headless/index.ts— clean.yarn prettier --checkon all changed files — clean.yarn lint:tsc— clean (exit 0).RAMPS_ORDER_DETAILS); post-fix, lands back at the playground withonOrderCreated+onClose({ reason: 'completed' })and no OrderDetails screen.Changelog
CHANGELOG entry: null
Related issues
Fixes: TRAM-3529
Continuity: #29612 (Phase 7 — structured errors as data). #29340 (Phase 6 — order success callback + stack unwind). #29338 (Phase 5 — Headless Host + quote-first start).
Manual testing steps
Screenshots/Recordings
Before
N/A — Phase 8 changes callback plumbing only.
After
N/A — no user-facing UI changes.
But here's two videos anyways of happy path and not-happy path (user cancels):
Screen.Recording.2026-05-11.at.4.29.03.PM.mov
Screen.Recording.2026-05-11.at.4.30.55.PM.mov
Pre-merge author checklist
Performance checks (if applicable)
Pre-merge reviewer checklist
Note
Medium Risk
Changes headless session lifecycle/termination behavior and adds navigation-state inspection to avoid premature closes during
navigation.reset, which could impact checkout flow completion if incorrect. Covered by new unit tests, but touches critical orchestration paths.Overview
Headless buy now reliably emits a terminal
onClose({ reason: 'user_dismissed' })when the user leaves the headless flow.Adds
useHeadlessSessionDismissal(exported from the headless barrel) and wires it intoHeadlessHostso that unmounting the host closes any still-live session, with a stack-rebuild guard that checksnavigation.getState()to avoid closing duringnavigation.reset-driven route key swaps.Updates
HeadlessHostto also callcloseSession(..., { reason: 'user_dismissed' })synchronously from the back/cancel handler, and expands tests to ensureonClosefires exactly once across back/cancel, unmount, late promise rejections, and Phase 6/7 terminal paths. Documentation inPLAN.mdis updated to mark Phase 8 complete and capture follow-up roadmap/design notes.Reviewed by Cursor Bugbot for commit 08b88f5. Bugbot is set up for automated code reviews on this repo. Configure here.